fix(auth): refresh provider credentials per request (ENG-2116) - #421
fix(auth): refresh provider credentials per request (ENG-2116)#421lucas-koontz wants to merge 2 commits into
Conversation
Refs: ENG-2116
There was a problem hiding this comment.
🔵 Needs a closer look
It changes core authentication semantics (typed 401 handling + bounded retries) while also upgrading the OpenAI SDK major version, which can have broad runtime impact beyond the touched call sites.
Pull request overview
This PR updates Anton’s provider-auth handling so long-running sessions can transparently recover from a single typed 401 by re-reading refreshed credentials per request (not rebuilding the turn), while ensuring confirmed credential refusals propagate with an attributed failing role.
Changes:
- Introduces a canonical
ProviderAuthError(typed 401) and updates OpenAI/Anthropic mappers to raise it for HTTP 401. - Adds a bounded “confirm once” retry at the
LLMClientboundary for both non-streaming and streaming calls (streaming retries only before the first event), stamping the failing role on confirmed refusals. - Enables OpenAI dynamic credentials via an async API-key supplier, and updates dependency constraints/tests to pin this behavior.
File summaries
| File | Description |
|---|---|
| uv.lock | Pins openai>=2.21.0 in the lockfile to support async API-key suppliers. |
| pyproject.toml | Updates runtime dependency constraint for OpenAI SDK to >=2.21.0. |
| anton/cli.py | Updates the CLI runtime dependency requirement for OpenAI SDK. |
| anton/core/llm/provider.py | Adds ProviderAuthError(ConnectionError) with optional role attribution. |
| anton/core/llm/openai.py | Maps 401 → ProviderAuthError and supports async API-key suppliers for per-request credential refresh. |
| anton/core/llm/anthropic.py | Maps 401 → ProviderAuthError for canonical auth refusal handling. |
| anton/core/llm/client.py | Implements one-time auth confirmation retry and role attribution for confirmed refusals (including streaming before first event). |
| anton/core/session.py | Updates auth predicate to canonical type and ensures confirmed verifier auth errors aren’t swallowed by broader exception handling. |
| tests/test_status_error_mapper.py | Updates expectations to type-based 401 mapping and pins provider-auth predicate behavior. |
| tests/test_session_auth_error_reraise.py | Ensures the session does not spend retry budget on confirmed auth refusals and uses canonical typing. |
| tests/test_client.py | Adds coverage for confirmation retry, role attribution, and streaming replay guard behavior. |
| tests/test_thalamus.py | Adds coverage that router auth refusal confirms once then fails open (fallback to planning). |
| tests/test_verifier_truncation.py | Ensures confirmed verifier auth refusal is terminal and not retried as truncation. |
| tests/test_openai_dynamic_api_key.py | Regression test proving OpenAI requests re-read the API key without rebuilding the provider. |
| tests/test_chat_error_action_default.py | Pins default UI action behavior to canonical typed auth errors (type-based setup vs retry). |
Review details
Suppressed comments (1)
anton/core/llm/client.py:71
- Same as above: stamping
roleby mutating the raised ProviderAuthError instance can leak state if the same exception object is reused (e.g., in mocks or cached exceptions). Prefer re-raising a new ProviderAuthError withfrom exconce confirmation is exhausted.
if yielded or not confirmation.take():
exc.role = role
raise
- Files reviewed: 14/15 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
alecantu7
left a comment
There was a problem hiding this comment.
Adversarial review — a38aa231 against staging 8a555f33
Reviewed as the first link in the ENG-2116 chain (this → cowork-server#428 → cowork#778). Deep pass across all three repos, six dimensions with adversarial verifiers: 11 candidates, 8 refuted, 3 survived — one here (low, inline), one on #428, one on #778.
The fix works. I verified the central claim by executing the composition rather than reading it, through a real openai.AsyncOpenAI client over httpx.MockTransport: a 401 on Bearer token-a with the live credential rotating to token-b at that instant, driven through OpenAIProvider(..., flavor=FLAVOR_MINDS_PASSTHROUGH) and LLMClient.plan().
RESULT: recovered -> done
AUTH HEADERS SEEN: ['Bearer token-a', 'Bearer token-b']
That is the incident, reproduced and recovered. Exactly two calls, second one on the rotated token.
What else came back clean
- The classifier does not over-reach. Differential-tested against
origin/staging's for a corpus of error shapes: 402, 403, 429, 5xx, billing, rate-limit and model-error classifications are byte-identical. No silent reclassification — which was the outcome I was most worried about, since 402 wallet-empty and 429 quota-vs-TPM are distinct user-facing cards in this stack. - Negative cases hold: a generic
ConnectionError, incidental "invalid api key" text from an unrelated source, and a bare 401 all stay out of the typed path. - Required vs optional roles behave as the ticket specifies — two typed refusals surface
provider_authon planning, coding and verifier; router, history-summary and background-memory probes stay fail-open. - No retry stacking: the bounded confirmation does not compound with SDK-level
max_retries. Real HTTP attempts counted, not inferred.
One candidate I raised and then refuted, recorded so it isn't re-litigated: "a 401 on the end-of-turn completion verifier now kills a turn whose answer already reached the user" — raised high, did not survive.
Not blocking, but worth knowing before this merges first
This PR is fine on its own. The chain around it has one gap, filed on cowork-server#428: that PR narrows provider_auth to the typed ProviderAuthError you introduce here, but hosted/web turns run in a scratchpad pod whose anton image is pinned in scratchpad-controller to 61ec5db6 (staging) / d4f1db2c (prod) — neither of which carries this PR, so both still raise a bare ConnectionError and every hosted 401 downgrades to a generic error. Details there; mentioning it here only because this repo is where the typed error originates and the merge order puts you first.
Verdict: COMMENT. One low finding inline — a coverage gap, not a defect. Not an approval; posting at Alejandro's request rather than as a requested reviewer.
|
|
||
|
|
||
| async def test_live_requests_reread_api_key_without_rebuilding_provider(): | ||
| current_token = "token-a" |
There was a problem hiding this comment.
LOW / confirmed / introduced — the one behaviour ENG-2116 was filed for is the one behaviour no test asserts.
This PR's thesis is a composition of two mechanisms: (1) the provider re-reads the credential per request, and (2) LLMClient spends exactly one bounded retry on a typed 401. Both are well tested — in isolation, and neither test touches the other mechanism.
- This file drives a real
openai.AsyncOpenAIoverhttpx.MockTransportwith a rotating token, and proves the per-request re-read. But every response is a200:grep -n 401 tests/test_openai_dynamic_api_key.py→ no match._call_with_auth_confirmationis never entered. tests/test_client.py(+test_thalamus.py::test_confirmed_router_auth_failure_falls_back_to_planning) exercises the retry thoroughly — but againstAsyncMock(spec=LLMProvider)objects with no credential and no HTTP layer, so "the retry used the NEW token" is not an assertion any of them can make.- cowork-server
test_build_llm_client.py::test_local_minds_cloud_provider_rereads_runtime_credentialawaits theapi_key_providercallables directly (token-A → token-B →ProviderAuthError) but never issues a request through them.
grep -rn 'api_key_provider' tests/ across anton and cowork-server returns exactly two files, and neither contains a 401. Nothing tests the join.
This is explicitly not a claim the fix is broken — I executed the composition against unmodified HEAD and it recovers correctly (headers ['Bearer token-a', 'Bearer token-b'], result done). The exposure is refactoring: the two halves could drift apart — retry moved below the provider, credential resolved once per logical call, an except ProviderAuthError added inside OpenAIProvider.complete — and the suite would stay green while the 1.47M-token symptom returned. That matters more here than elsewhere because this repo merges first, so a regression would land unobserved by the other two PRs' tests.
Fix: promote the probe into this file as a second test. No new fixtures needed — it is this test's MockTransport plus a 401 branch and an LLMClient wrapper, ~40 lines. Assert both the recovery and the header sequence; the header sequence is what distinguishes "retried and got lucky" from "retried with the rotated credential".
async def test_a_401_on_the_stale_token_is_confirmed_with_the_rotated_one():
live = {"cred": "token-a"}
seen: list[str] = []
async def api_key_provider() -> str:
return live["cred"]
def handler(request: httpx.Request) -> httpx.Response:
auth = request.headers["authorization"]
seen.append(auth)
if auth == "Bearer token-a":
live["cred"] = "token-b" # the desktop refresh lands mid-turn
return httpx.Response(401, json={"error": {"message": "Token is not active", "code": "invalid_token"}})
return httpx.Response(200, json={...}) # same body as the test above
# assert the turn completes AND seen == ["Bearer token-a", "Bearer token-b"]The 401 body is the real one from the incident — Keycloak returned invalid_token / "Token is not active" for all seven named failures — so the fixture reproduces the production shape rather than a lookalike.
There was a problem hiding this comment.
TLDR; Agreed, the join was untested. Added your test, near enough verbatim.
Fixed in 095c17ae — test_a_401_on_the_stale_token_is_confirmed_with_the_rotated_one uses the invalid_token / "Token is not active" body and asserts ["Bearer token-a", "Bearer token-b"], so it separates "retried and got lucky" from "retried with the rotated credential".
Confirmed it dies under both mutants: the retry wrapper removed from plan(), and the supplier ignored in favour of the static key. The second one also killed the pre-existing rotation test, which is the right shape.
There was a problem hiding this comment.
TLDR; You asked for a test on the composition, not the two halves in isolation. Added in 095c17a.
test_a_401_on_the_stale_token_is_confirmed_with_the_rotated_one (tests/test_openai_dynamic_api_key.py:89) serves a 401 on the stale token, rotates the credential mid-flight, and returns 200 on the retry, asserting the header sequence rather than just the recovery. Verified it dies under two independent mutants.
…y seam AsyncAzureOpenAI._prepare_options never chains to super(), so a callable api_key is never awaited and the client sends an empty api-key header on every request. Refuse the combination at construction instead of failing on the first call. Add the test for the composition the fix actually depends on: a 401 on the stale token, the credential rotating mid-flight, and a 200 on the retry, asserting the header sequence rather than just the recovery. Both halves were tested in isolation and neither could catch the two drifting apart. Also cover the structured-output role stamps, and record why a confirmed verifier 401 propagates where 402 and 403 on the same call latch quietly.
There was a problem hiding this comment.
🟢 Approval recommended
The implementation and tests consistently enforce typed 401 confirmation/propagation behavior and per-request credential refresh, with only a minor docstring grammar nit noted.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
tests/test_openai_dynamic_api_key.py:90
- Docstring grammar: “The join the two halves …” is ungrammatical; consider rephrasing for clarity.
- Files reviewed: 14/15 changed files
- Comments generated: 0 new
- Review effort level: Lite
alecantu7
left a comment
There was a problem hiding this comment.
Verification pass — 095c17ae
My finding is closed. test_a_401_on_the_stale_token_is_confirmed_with_the_rotated_one exists, uses the production 401 body (invalid_token / "Token is not active"), and asserts ["Bearer token-a", "Bearer token-b"] — which is what separates retried and got lucky from retried with the rotated credential.
Mutation-verified rather than read: neutralising the per-request supplier at openai.py:870 (client_api_key = api_key) turns 2 tests red, the new one included. Guard armed.
The Azure refusal in the same commit is a better catch than my finding was, and worth stating out loud since it is the kind of thing that would only have surfaced in production: AsyncAzureOpenAI._prepare_options fully overrides the base hook and never chains to super(), so _refresh_api_key never runs and the supplier is never awaited — while the base __init__ has already replaced the callable api_key with "". An Azure install would have sent an empty api-key header and 401'd forever. The ENG-2116 fix would have created that; refusing the combination at construction is right.
One thing to carry to the sibling: cowork-server#428 pins anton to a38aa231, which is this PR's previous head — so it is testing against an anton without the Azure refusal. Raised there; flagging here because the merge order puts you first and the re-pin is downstream of you.
Scope note: at Alejandro's direction this was a verification pass on my finding, so the rest of this commit's ~185 new lines are not reviewed by me.
Verdict: COMMENT. Not an approval.
alecantu7
left a comment
There was a problem hiding this comment.
Remaining-lines review — 095c17ae
Covering the rest of this commit, which my last pass explicitly excluded. No findings.
The Azure refusal holds up. The diagnosis is true of the SDK the lockfile resolves: AsyncAzureOpenAI._prepare_options fully overrides the base hook and never chains to super(), so _refresh_api_key never runs and the supplier is never awaited — while the base __init__ has already replaced the callable api_key with "". Refusing at construction is the right shape: loud and early, rather than an empty api-key header 401'ing forever at first use.
One candidate was raised and refuted — "the refusal hardcodes an SDK fact already false at the top of anton's declared openai range." The SDK facts checked out; the finding died on three independent grounds.
I also checked the refusal doesn't over-reach into non-Azure deployments via _is_azure_endpoint / api_version, since a false refusal would be a hard startup failure for a working install. It doesn't.
Worth carrying downstream: cowork-server#428 pins anton to a38aa231 — this PR's previous head — so it is testing against an anton without this refusal. Raised there, along with a bigger problem in the same area: providers.py gates the api_key_provider kwarg on credential presence rather than on anton's capability, so the branch = "main" flip that repo's pyproject instructs would TypeError on every signed-in desktop MindsHub turn. Relevant here because the merge order puts you first.
Verdict: COMMENT. Not an approval.
alecantu7
left a comment
There was a problem hiding this comment.
Approving
Reviewed across three rounds — the original fix, the follow-up commit, and a final sweep of the lines the earlier passes had explicitly excluded. No findings outstanding.
What was verified, by execution rather than reading
- The core fix recovers the actual incident. Driving
OpenAIProvider+LLMClient.plan()through a realopenai.AsyncOpenAIoverhttpx.MockTransport, with a 401 ontoken-aand the credential rotating at that instant:RESULT: recovered -> done,AUTH HEADERS SEEN: ['Bearer token-a', 'Bearer token-b']. Exactly two calls, second on the rotated token. - My one finding is closed and armed.
test_a_401_on_the_stale_token_is_confirmed_with_the_rotated_oneuses the production 401 body (invalid_token/ "Token is not active") and asserts the header sequence — which is what separates retried and got lucky from retried with the rotated credential. Neutralising the per-request supplier read atopenai.py:870turns 2 tests red, that one included. - The classifier does not over-reach. Differential-tested against
origin/staging's for a corpus of error shapes: 402, 403, 429, 5xx, billing, rate-limit and model-error classifications are byte-identical. That was the outcome I was most worried about, since 402 wallet-empty and 429 quota-vs-TPM are distinct user-facing cards in this stack. - Required vs optional roles behave as the ticket specifies, and the bounded confirmation does not stack with SDK-level
max_retries— real HTTP attempts counted, not inferred. - The Azure refusal holds up, including that it does not over-reach into non-Azure deployments via
_is_azure_endpoint/api_version. Catching thatAsyncAzureOpenAI._prepare_optionsnever chains tosuper()— so the supplier is never awaited while the base__init__has already blanked the callableapi_key— was a better catch than my own finding was, and it is a hole this fix would otherwise have opened.
Two candidate findings were raised against this PR across the rounds and both died under refutation.
Two things to carry forward — neither blocks this
- cowork-server#428 pins anton to
a38aa231, this PR's previous head, so it is currently testing against an anton without the Azure refusal. Once this merges, that pin must move to the resulting staging commit — the pin is the seam, and it lives in a different repo. providers.pyin #428 gates theapi_key_providerkwarg on credential presence rather than on anton's capability, so thebranch = "main"flip its ownpyproject.tomlinstructs wouldTypeErroron every signed-in desktop MindsHub turn. Raised there; noting it here because this PR is what introduces the kwarg, and this is first in the merge order.
Approved on Alejandro's call as reviewer. Merging is his, and the declared order still stands: this first, then cowork-server#428, then cowork#778.
There was a problem hiding this comment.
🔵 Needs a closer look
It changes core authentication error classification and retry semantics across multiple critical call paths (including streaming), so it warrants final human verification despite strong test coverage.
Review details
- Files reviewed: 14/15 changed files
- Comments generated: 3
- Review effort level: Lite
| class ProviderAuthError(ConnectionError): | ||
| """Raised when a provider rejects its credential with HTTP 401. | ||
|
|
||
| The type distinguishes an authentication refusal from unrelated | ||
| ``ConnectionError`` failures. It remains a ``ConnectionError`` subclass so | ||
| in-process callers written against the previous 401 mapping keep working. | ||
| The client stamps ``role`` on a confirmed refusal so hosts can attribute | ||
| the recovery action to the provider that actually failed. | ||
| """ |
There was a problem hiding this comment.
Fixed in 1e368f16 — the ProviderAuthError docstring now defines terminal refusal as either failed confirmation or the first refusal after streaming output makes replay unsafe.
| def _is_provider_auth_error(exc: BaseException) -> bool: | ||
| """A provider-auth 401 — anton's "Invalid API key — …" copy from | ||
| `openai.py`/`anthropic.py` (ENG-1310): the credential is wrong, not the | ||
| request, so retrying can't succeed either. The substring match mirrors | ||
| cowork-server's `turn_errors.is_auth_error()`; the `isinstance` check is | ||
| an anton-only narrowing on top of it (both 401 raise sites always type | ||
| it this way, so it's a no-op in practice) — anything else (a bare | ||
| "temporarily unavailable" ConnectionError) is a different failure. | ||
|
|
||
| Shared by both `turn_stream` re-raise sites so the check can't drift | ||
| between them (review feedback on ENG-1310). | ||
| """Whether ``exc`` is the canonical provider HTTP-401 mapping. | ||
|
|
||
| ``LLMClient`` already made the one bounded confirmation attempt before | ||
| this reaches the session. The session must therefore propagate this typed | ||
| second refusal, while unrelated ``ConnectionError`` values keep their | ||
| existing recovery behavior. | ||
| """ |
There was a problem hiding this comment.
Fixed in 1e368f16 — the session wording now covers both a failed confirmation and a first refusal after streaming output, while preserving unrelated ConnectionError recovery.
|
|
||
|
|
||
| async def test_a_401_on_the_stale_token_is_confirmed_with_the_rotated_one(): | ||
| """The join the two halves of ENG-2116 are only useful together. |
There was a problem hiding this comment.
Fixed in 1e368f16 — corrected the docstring to read “The two halves of ENG-2116 are only useful together.”
There was a problem hiding this comment.
🟡 Changes recommended
Azure callable-key refusal should guard against any callable client_api_key (not only api_key_provider), and two updated docstrings currently misstate the streaming/confirmation contract.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
anton/core/llm/provider.py:492
- The docstring says the client stamps
roleonly on a “confirmed” refusal, but_stream_with_auth_confirmationalso stampsrolewhen a ProviderAuthError occurs after any stream events have been yielded (no retry is attempted to avoid replay). Updating the wording avoids misleading future readers about whenroleis set.
The type distinguishes an authentication refusal from unrelated
``ConnectionError`` failures. It remains a ``ConnectionError`` subclass so
in-process callers written against the previous 401 mapping keep working.
The client stamps ``role`` on a confirmed refusal so hosts can attribute
the recovery action to the provider that actually failed.
anton/core/session.py:597
- This docstring claims the session only sees the “typed second refusal” because LLMClient already did the one confirmation attempt, but in streaming flows LLMClient intentionally does not retry once any event has been yielded. A ProviderAuthError reaching the session can therefore be either a failed confirmation or a first post-yield refusal; the wording should cover both.
``LLMClient`` already made the one bounded confirmation attempt before
this reaches the session. The session must therefore propagate this typed
second refusal, while unrelated ``ConnectionError`` values keep their
existing recovery behavior.
"""
- Files reviewed: 14/15 changed files
- Comments generated: 1
- Review effort level: Lite
| client_api_key = api_key_provider if api_key_provider is not None else api_key | ||
| if api_version and _is_azure_endpoint(base_url): | ||
| # Azure OpenAI: use the dedicated client which handles deployment | ||
| # URL construction and api-version automatically. | ||
| if api_key_provider is not None: | ||
| # AsyncAzureOpenAI._prepare_options fully overrides the base | ||
| # hook and never chains to super(), so AsyncOpenAI's | ||
| # _refresh_api_key never runs and the supplier is never | ||
| # awaited. The base __init__ has already replaced a callable | ||
| # api_key with "", so the client would send an empty api-key | ||
| # header on every request and 401 forever. Refuse at | ||
| # construction instead of failing on the first call. | ||
| raise EndpointConfigurationError( | ||
| "Azure OpenAI cannot refresh credentials per request: " | ||
| "AsyncAzureOpenAI ignores a callable api_key. Pass a " | ||
| "static api_key for Azure endpoints." | ||
| ) |
There was a problem hiding this comment.
Agreed, and fixed in 303bf7c on the main-targeted hotfix (anton#429). The guard now keys on the resolved value, callable(client_api_key), so a supplier passed through api_key is refused too. Test added: test_azure_refuses_a_callable_passed_as_the_static_api_key.
There was a problem hiding this comment.
🔵 Needs a closer look
It changes core authentication error classification and retry/streaming behavior across the LLM client and session boundary, which warrants final human validation despite strong test coverage.
Review details
- Files reviewed: 14/15 changed files
- Comments generated: 3
- Review effort level: Lite
| The type distinguishes an authentication refusal from unrelated | ||
| ``ConnectionError`` failures. It remains a ``ConnectionError`` subclass so | ||
| in-process callers written against the previous 401 mapping keep working. | ||
| The client stamps ``role`` on a confirmed refusal so hosts can attribute | ||
| the recovery action to the provider that actually failed. |
| ``LLMClient`` already made the one bounded confirmation attempt before | ||
| this reaches the session. The session must therefore propagate this typed | ||
| second refusal, while unrelated ``ConnectionError`` values keep their | ||
| existing recovery behavior. |
|
|
||
|
|
||
| async def test_a_401_on_the_stale_token_is_confirmed_with_the_rotated_one(): | ||
| """The join the two halves of ENG-2116 are only useful together. |
|
Superseded by #429. That PR replayed this change onto main as an ENG-2116 hotfix, and 0164706 Sync main into staging backmerged it, so staging already carries every mechanism here plus three review fixes that only landed on the hotfix branch: the broadened Azure callable(client_api_key) guard, the corrected ProviderAuthError docstring, and the test docstring typo. The remaining delta against staging is 183 commits of unrelated drift, so resolving these conflicts would revert other work to re-land a fix that already shipped. The three Copilot threads here are addressed on staging. |
User story
As a signed-in Cowork user
I want an active turn to adopt refreshed MindsHub credentials and recover from one isolated authentication refusal
So that long-running work is not discarded or blamed on my account
Why this matters
A long-running turn can retain a ten-minute MindsHub JWT after the desktop refreshes it. The next model call then fails as if the user's session were invalid, discarding substantial work and suggesting a reconnect that cannot repair the stale in-memory credential. Seven observed failures discarded about 1.47 million tokens, including work from a paid external user.
Acceptance criteria
provider_auth; optional probes remain fail-open after confirmation.provider_auth.How to test
ConnectionError, auth-looking text, a bare 401, and non-auth gateway statuses and verify their existing mappings remain intact.Notes for the reviewer
Provider instances stay alive while credentials rotate. A runtime supplier feeds the OpenAI SDK before each main-process request. Static provider settings remain static, and exported scratchpad subprocess configuration keeps its construction-time credential because subprocess hot-swap needs a separate IPC contract.
Confirmation is typed and bounded. Only
ProviderAuthErroris retried, only once, and streaming retries only before the first event. Required planning, coding, and verifier calls propagate a confirmed refusal; optional router, history-summary, and background-memory probes retain their fail-open contract.The terminal error carries the failing role. cowork-server uses that role to preserve the correct Reconnect or update-key action in mixed-provider configurations.
Merge this first. cowork-server pins this exact staging commit before its own PR can ship.
Verified locally
git diff --checkShips with
Merge order: this Anton PR first, then cowork-server, then cowork desktop.
Deployment: cowork-server is the sole preview/deploy anchor.
Review round — 095c17a
Addresses review feedback from @alecantu7 and Copilot, plus a self-review pass.
api_key_providerat construction.AsyncAzureOpenAI._prepare_optionsnever chains to super, so the supplier is never awaited and the client sends an emptyapi-keyon every request. Unreachable from cowork-server today, but it accepted the combination silently.test_a_401_on_the_stale_token_is_confirmed_with_the_rotated_one): a 401 on the stale token, the credential rotating mid-flight, a 200 on the retry, asserting the header sequence. Both halves of the fix were tested in isolation and nothing pinned the join. Verified it dies under two independent mutants._generate_object_with's wrapper and itsplanning/codingroles had no test.Not changed: Copilot's suggestion to stop mutating
exc.role. Every 401 constructs a fresh instance in production and the wrappers never nest; re-wrapping would add a link to the__cause__chain thatturn_errors._http_error_contextwalks.Verified: 172 focused, 2,810 full suite.